#!/usr/bin/python
# -*- coding: utf-8 -*-
# @version        : 1.0
# @Creaet Time    : 2021/10/16 12:57 
# @File           : case05.py
# @IDE            : PyCharm
# @desc           : 案例05，安全认证


"""
安全性官方文档：https://fastapi.tiangolo.com/zh/tutorial/security/

FastAPI 安全认证默认使用 OAuth2 规范，它定义了几种处理身份认证和授权的方法（这里主要使用密码授权模式）。

它是一个相当广泛的规范，涵盖了一些复杂的使用场景。

它包括了使用「第三方」进行身份认证的方法。

FastAPI 在 fastapi.security 模块中为每个安全方案提供了几种工具，这些工具简化了这些安全机制的使用方法。

并且这些工具已经自动地被集成到交互式文档系统中
"""


"""
1. OAuth2 认证示例
2. 基于 Password 和 Bearer token 的 OAuth2 认证
3. 使用（哈希）密码和 JWT Bearer 令牌的 OAuth2
"""


from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from typing import Optional


case05 = APIRouter()


@case05.get("/", summary="案例5初始接口")
def case05_root():
    return "case05_root"


"""
指明客户端用来请求 Token 的URL地址
使用 OAuth2PasswordBearer 需要安装 python-multipart，这是因为 OAuth2 使用 Form 表单发送 username 和 password。
OAuth2PasswordBearer 是接收URL参数的一个类，客户端会向该URL发送 username 和 password，然后得到一个 Token 值
OAuth2PasswordBearer 并不会创建响应的URL路径操作，只是指明了客户端用来请求 Token 的URL地址

tokenUrl URL地址前不会默认添加引入路由时的前缀 prefix，所以需要手动加上

当请求 OAuth2PasswordBearer 提供的URL地址时，FastAPI会检查请求头信息，如果没有找到Authorization头信息，或者头信息的内容不是 Bearer token，他会返回 401 未授权
由于在fastapi交互文档中不能修改请求头, 可以使用postman调试工具, 在postman中, 提供了一个专门的认证选项(Authorization), 提供了一些常见的认证可供选择
选择Bearer Token认证, 在右边会出现Token的输入框, 我们随便输入一个值, 点击发送, 可以看到返回的不再是401, 而是接口的正常返回值
"""
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/study/case05/token")


# ====================================1. OAuth2 认证====================================


@case05.get("/oauth2", summary="OAuth2 认证")
async def oauth2(token: str = Depends(oauth2_scheme)):
    """
    使用 OAuth2 认证依赖项后，在交互式接口文档中，您的路径操作在右上角有一个小锁，您可以单击它

    如果单击它，您会得到授权，可以输入 username 和 password（以及其他可选字段）

    如果未授权直接请求此接口，会返回 401 Not authenticated 未授权

    我们随便输入用户名密码后, 点击Authorize按钮, 发现它报错说Auth Error Error: Not Found, 这是因为我们还没有实现这个表单格式的登录接口

    也就是说这个参数tokenUrl只是为了方便fastapi的文档网页的认证使用, 在前后端实际项目中并不会起到作用
    """
    return {"token": token}


# ====================================2. 基于 Password 和 Bearer token 的 OAuth2 认证====================================


"""
OAuth2PasswordRequestForm 是一个类依赖项，声明了如下的请求表单：
- username。
- password。
- 一个可选的 scope 字段，是一个由空格分隔的字符串组成的大字符串。
- 一个可选的 grant_type.
- 一个可选的 client_id（我们的示例不需要它）。
- 一个可选的 client_secret（我们的示例不需要它）。

Tip：
OAuth2 规范实际上要求 grant_type 字段使用一个固定的值 password，但是 OAuth2PasswordRequestForm 没有作强制约束。
如果你需要强制要求这一点，请使用 OAuth2PasswordRequestFormStrict 而不是 OAuth2PasswordRequestForm。

Info：
OAuth2PasswordRequestForm 并不像 OAuth2PasswordBearer 一样是 FastAPI 的一个特殊的类。
OAuth2PasswordBearer 使得 FastAPI 明白它是一个安全方案。所以它得以通过这种方式添加到 OpenAPI 中。
但 OAuth2PasswordRequestForm 只是一个你可以自己编写的类依赖项，或者你也可以直接声明 Form 参数。
但是由于这是一种常见的使用场景，因此 FastAPI 出于简便直接提供了它。
"""


# 模仿数据库用户表
fake_users_db = {
    "admin": {
        "username": "admin",
        "full_name": "Admin",
        "email": "guest@example.com",
        "hashed_password": "fakehashedadmin",
        "disabled": False,
    },
    "guest": {
        "username": "guest",
        "full_name": "Guest",
        "email": "guest@example.com",
        "hashed_password": "fakehashedguest",
        "disabled": True,
    },
}


def fake_hash_password(password: str):
    """
    模仿 密码加密
    """
    return "fakehashed" + password


class User(BaseModel):
    """
    用户模型
    """
    username: str
    email: Optional[str] = None
    full_name: Optional[str] = None
    disabled: Optional[bool] = None


class UserInDB(User):
    """
    输入模型
    """
    hashed_password: str


def get_user_info(db, username: str):
    """
    获取用户详细信息，如果不存在则返回 None
    """
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)  # UserInDB(**user_dict) 表示：直接将 user_dict 的键和值作为关键字参数传递


def fake_decode_token(token):
    """
    获取用户详细信息，如果不存在则返回 None
    这里目前没有提供任何安全性
    详见下一个版本
    """
    user = get_user_info(fake_users_db, token)
    return user


async def get_current_user(token: str = Depends(oauth2_scheme)):
    """
    获取当前登录的用户，没有则返回 401 无效的身份验证凭据

    我们在此处返回的值为 Bearer 的额外响应头 WWW-Authenticate 也是规范的一部分。

    任何的 401「未认证」HTTP（错误）状态码都应该返回 WWW-Authenticate 响应头。

    对于 bearer 令牌（我们的例子），该响应头的值应为 Bearer。

    实际上你可以忽略这个额外的响应头，不会有什么问题。

    但此处提供了它以符合规范。

    而且，（现在或将来）可能会有工具期望得到并使用它，然后对你或你的用户有用处。

    这就是遵循标准的好处...
    """
    user = fake_decode_token(token)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="无效的身份验证凭据",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user


async def get_current_active_user(current_user: User = Depends(get_current_user)):
    """
    判断当前登录用户是否可用，不可用则返回 400 非活动用户，可用则返回
    """
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="用户不可用")
    return current_user


@case05.post("/token", summary="用户认证")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """

    我们在上面使用 OAuth2PasswordBearer 提供的 tokenUrl 只是用于让 FastAPI 明白这是一个安全方案。所以它得以通过这种方式添加到 OpenAPI 中。

    而我们需要重写 tokenUrl 编写用户认证逻辑，路由路径需要与 tokenUrl URL地址一致，
    注意：tokenURL 是不会默认添加路由前缀的，而这里会默认添加路由前缀

    token 接口的响应必须是一个 JSON 对象。

    它应该有一个 token_type。在我们的例子中，由于我们使用的是「Bearer」令牌，因此令牌类型应为「bearer」。

    并且还应该有一个 access_token 字段，它是一个包含我们的访问令牌的字符串。

    对于这个简单的示例，我们将极其不安全地返回相同的 username 作为令牌。
    """
    user_dict = fake_users_db.get(form_data.username)
    if not user_dict:
        raise HTTPException(status_code=400, detail="用户名不存在")
    user = UserInDB(**user_dict)
    hashed_password = fake_hash_password(form_data.password)  # 获取加密后的密码
    if not hashed_password == user.hashed_password:
        raise HTTPException(status_code=400, detail="密码不正确")

    return {"access_token": user.username, "token_type": "bearer"}


@case05.get("/get/user/me", summary="获取当前登录的用户")
async def get_user_me(current_user: User = Depends(get_current_active_user)):
    """
    获取当前登录的用户

    注意：交互接口文档刷新后，登录后的状态会取消
    """
    return current_user


# ====================================2. 使用（哈希）密码和 JWT Bearer 令牌的 OAuth2====================================


"""
JWT 表示 「JSON Web Tokens」。https://jwt.io/

它是一个将 JSON 对象编码为密集且没有空格的长字符串的标准。

通过这种方式，你可以创建一个有效期为 1 周的令牌。然后当用户第二天使用令牌重新访问时，你知道该用户仍然处于登入状态。
一周后令牌将会过期，用户将不会通过认证，必须再次登录才能获得一个新令牌。

我们需要安装 python-jose 以在 Python 中生成和校验 JWT 令牌：pip install python-jose[cryptography]

PassLib 是一个用于处理哈希密码的很棒的 Python 包。它支持许多安全哈希算法以及配合算法使用的实用程序。推荐的算法是 「Bcrypt」：pip install passlib[bcrypt]
"""


"""安全的随机密钥，该密钥将用于对 JWT 令牌进行签名，生成随机密钥，linux下执行：openssl rand -hex 32"""
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
"""用于设定 JWT 令牌签名算法"""
ALGORITHM = "HS256"
"""令牌过期时间，30分钟"""
ACCESS_TOKEN_EXPIRE_MINUTES = 30


"""模仿数据库用户表"""
jwt_fake_users_db = {
    "admin": {
        "username": "admin",
        "full_name": "Admin",
        "email": "admin@example.com",
        "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",  # 密码：secret
        "disabled": False,
    },
}


"""定义一个将在令牌端点中用于响应的 Pydantic 模型"""
class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    username: Optional[str] = None


"""PassLib 上下文处理。这将用于哈希和校验密码。"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

"""指明客户端用来请求 JWT 的URL地址"""
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/study/case05/jwt/token")


def verify_password(plain_password: str, hashed_password: str):
    """验证密码"""
    return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password):
    """密码加密"""
    return pwd_context.hash(password)


def jwt_get_user(db, username: str):
    """获取用户，不存在则然后 None"""
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def authenticate_user(fake_db, username: str, password: str):
    """验证用户密码"""
    user = jwt_get_user(fake_db, username)
    if not user:
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    """
    创建一个生成新的访问令牌的工具函数。
    """
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


@case05.post("/jwt/token", response_model=Token, summary="JWT 验证登录方式")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
    """
    重写 JWT 验证登录 的 tokenUrl

    JWT 验证登录流程：

    - 验证用户提交的用户名和密码，成功后获取到用户，不成功则返回 401 异常

    - 创建一个 timedelta 对象，用于设置令牌的过期时间

    - 创建一个真实的 JWT 访问令牌并返回它。

    信息：

    JWT 的规范中提到有一个 sub 键，值为该令牌的主题。

    使用它并不是必须的，但这是你放置用户标识的地方，所以我们在示例中使用了它。

    除了识别用户并允许他们直接在你的 API 上执行操作之外，JWT 还可以用于其他事情。

    例如，你可以识别一个 「汽车」 或 「博客文章」。

    然后你可以添加关于该实体的权限，比如「驾驶」（汽车）或「编辑」（博客）。

    然后，你可以将 JWT 令牌交给用户（或机器人），他们可以使用它来执行这些操作（驾驶汽车，或编辑博客文章），甚至不需要有一个账户，只需使用你的 API 为其生成的 JWT 令牌。

    使用这样的思路，JWT 可以用于更复杂的场景。

    在这些情况下，几个实体可能有相同的 ID，比如说 foo（一个用户 foo，一辆车 foo，一篇博客文章 foo）。

    因此，为了避免 ID 冲突，当为用户创建 JWT 令牌时，你可以在 sub 键的值前加上前缀，例如 username:。所以，在这个例子中，sub 的值可以是：username:johndoe。

    要记住的重点是，sub 键在整个应用程序中应该有一个唯一的标识符，而且应该是一个字符串。
    """
    user = authenticate_user(jwt_fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return {"access_token": access_token, "token_type": "bearer"}


async def jwt_get_current_user(token: str = Depends(oauth2_scheme)):
    """
    更新 get_current_user 以接收 JWT 令牌。

    解码接收到的令牌，对其进行校验，然后返回当前用户。

    如果令牌无效，立即返回一个 HTTP 错误。
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception
    user = jwt_get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user


async def jwt_get_current_active_user(current_user: User = Depends(jwt_get_current_user)):
    """获取当前登录的用户，并判断用户是否可用，不可用则返回 400 用户不可用"""
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="用户不可用")
    return current_user


@case05.get("/jwt/get/user/me", response_model=User)
async def jwt_get_user_me(current_user: User = Depends(jwt_get_current_active_user)):
    """获取当前登录的用户"""
    return current_user